查看原文
其他

是什么让 .NET 7 的 Min 和 Max 方法性能暴增了45倍?

INCerryCS 微软开发者MSDN 2022-11-23

点击上方蓝字

关注我们

(本文阅读时间:10分钟)

转自 INCerry,作者李时

谈到 .NET 性能改进,我们知道 Linq 中的 Min() 和 Max() 方法 .NET 7 比 .NET 6 有高达45倍的性能提升,当时 Benchmark 代码和结果如下所示:

[Params(1000)]public int Length { get; set; }
private int[] arr;
[GlobalSetup]public void GlobalSetup() => arr = Enumerable.Range(0, Length).ToArray();
[Benchmark]public int Min() => arr.Min();
[Benchmark]public int Max() => arr.Max();

可以看到有高达45倍的性能提升,那就有小伙伴比较疑惑,在 .NET 7 中到底是做了什么让它有如此大的性能提升?所以本文就通过 .NET 7 中的一些 pr 带大家一起探索下 .NET 7 的 Min() 和 Max() 方法是如何变快的




探索

首先我们打开 .NET Runtime 的仓库,里面包含了 .NET 运行时所有的代码,包括 CLR 和 BCL 库。

  • 仓库

    https://github.com/dotnet/runtime

然后我们熟练的根据命名空间 System.Linq 找到 Linq 所在的文件夹位置,如下所示:

可以看到很多 Linq 相关的方法都在这个文件夹内,让我们先来找一找 Max() 方法所对应的类。就是下方所示,我们可以看到刚好异步小王子 Stephen Toub 大佬提交了一个优化代码。 

然后我们点击 History 查看这个类的提交历史,我们发现 Stephen 大佬在今年多次提交代码,都是优化其性能。

找到 Stephen 大佬的第一个提交,我们发现在 Max 的代码中,多了一个特殊的路径,如果数据类型为 int[],那么就走单独的一个方法重载,并在这个重载中启用了 SIMD 向量化,代码如下所示:

SIMD 向量化在我之前的多篇文章中都有提到,它是 CPU 的特殊指令,使用它可以大幅度的增强运算性能,我猜这就是性能提升的原因。

我们可以看到在上面只为 int[] 做了优化,然后继续浏览了 Stephen 大佬的其它几个 PR,Stephen 大佬将代码抽象了一下,使用了泛型的特性,然后顺便为其它的基本值类型都做了优化。能享受到性能提升的有 byte sbyte ushort short uint int ulong long nuint nint。 

所以我们以最后一个提交为例,看看到底是用了什么 SIMD 指令,什么样的方法来提升的性能。抽取出来的核心代码如下所示:

private static T MinMaxInteger<T, TMinMax>(this IEnumerable<T> source) where T : struct, IBinaryInteger<T> where TMinMax : IMinMaxCalc<T>{ T value;
if (source.TryGetSpan(out ReadOnlySpan<T> span)) { if (span.IsEmpty) { ThrowHelper.ThrowNoElementsException(); }
// 判断当前平台是否支持使用Vector-128 或者 总数据长度是否小于128位 // Vector128是指硬件支持同时计算128位二进制数据 if (!Vector128.IsHardwareAccelerated || span.Length < Vector128<T>.Count) { // 进入到此路径,说明最基础的Vector128都不支持,那么直接使用for循环来比较 value = span[0]; for (int i = 1; i < span.Length; i++) { if (TMinMax.Compare(span[i], value)) { value = span[i]; } } } // 判断当前平台是否支持使用Vector-256 或者 总数据长度是否小于256位 // Vector256是指硬件支持同时计算256位二进制数据 else if (!Vector256.IsHardwareAccelerated || span.Length < Vector256<T>.Count) { // 进入到此路径,说明支持Vector128但不支持Vector256 // 那么进入128位的向量化的比较
// 获取当前数组的首地址,也就是指向第0个元素 ref T current = ref MemoryMarshal.GetReference(span); // 获取Vector128能使用的最后地址,因为整个数组占用的bit位有可能不能被128整除 // 也就是说最后的尾巴不够128位让CPU跑一次,那么就直接最后往前数128位,让CPU能完整的跑完 ref T lastVectorStart = ref Unsafe.Add(ref current, span.Length - Vector128<T>.Count);
// 从内存首地址加载0-127bit数据,作为最大值的基准 Vector128<T> best = Vector128.LoadUnsafe(ref current); // 计算下一个的位置,也就是偏移128位 current = ref Unsafe.Add(ref current, Vector128<T>.Count); // 循环比较 确保地址小于最后地址 while (Unsafe.IsAddressLessThan(ref current, ref lastVectorStart)) { // 此时TMinMax.Compare重载代码 => Vector128.Max(left, right); // Vector128.Max 会根据类型一一比较,每x位最大的返回, // 比如int就是每32位比较,详情可以看我后文的解析 best = TMinMax.Compare(best, Vector128.LoadUnsafe(ref current)); current = ref Unsafe.Add(ref current, Vector128<T>.Count); } // 最后一组Vector128进行比较 best = TMinMax.Compare(best, Vector128.LoadUnsafe(ref lastVectorStart));
// 由于Vector128最后的结果是128位,比如我们类型是int32,那么最后的结果就有 // 4个int32元素,我们还需要从这4个int32元素中找到最大的 value = best[0]; for (int i = 1; i < Vector128<T>.Count; i++) { // 这里 TMinMax.Compare就是简单的大小于比较 // left > right if (TMinMax.Compare(best[i], value)) { value = best[i]; } } } else { // Vector256执行流程和Vector128一致 // 只是它能一次性判断256位,举个例子就是一个指令8个int32 ref T current = ref MemoryMarshal.GetReference(span); ref T lastVectorStart = ref Unsafe.Add(ref current, span.Length - Vector256<T>.Count);
Vector256<T> best = Vector256.LoadUnsafe(ref current); current = ref Unsafe.Add(ref current, Vector256<T>.Count);
while (Unsafe.IsAddressLessThan(ref current, ref lastVectorStart)) { best = TMinMax.Compare(best, Vector256.LoadUnsafe(ref current)); current = ref Unsafe.Add(ref current, Vector256<T>.Count); } best = TMinMax.Compare(best, Vector256.LoadUnsafe(ref lastVectorStart));
value = best[0]; for (int i = 1; i < Vector256<T>.Count; i++) { if (TMinMax.Compare(best[i], value)) { value = best[i]; } } } } else { // 如果不是基本类型的数组,那么进入迭代器,使用原始方法比较 using (IEnumerator<T> e = source.GetEnumerator()) { if (!e.MoveNext()) { ThrowHelper.ThrowNoElementsException(); }
value = e.Current; while (e.MoveNext()) { T x = e.Current; if (TMinMax.Compare(x, value)) { value = x; } } } }
return value;}

以上就是代码的解析,相信很多人疑惑的地方就是 Vector128.Max 做了什么,我们可以构造一个代码,让大家简单的看出来发生了什么。代码和运行结果如下所示:

// 定义一个数组var array = new int[] { 4, 3, 2, 1, 1, 2, 3, 4 };
// 拿到数组首地址指针ref int current = ref MemoryMarshal.GetReference(array.AsSpan());
// 从首地址加载128位数据,上面是int32// 所以x = 4, 3, 2, 1var x = Vector128.LoadUnsafe(ref current);
// 偏移128位以后,继续加载128位数据// 所以y = 1, 2, 3, 4var y = Vector128.LoadUnsafe(ref Unsafe.Add(ref current, Vector128<int>.Count));
// 使用Vector128.Max进行计算var result = Vector128.Max(x, y);
// 打印输出结果x.Dump();y.Dump();result.Dump();

从运行的结果可以看到,result 中保存的是 x 和 y 对应位置的最大值,这样是不是就觉得清晰明了,Stephe 大佬上文的代码就是做了这样一个操作。

同样,如果我们把 int32 换成 int64,也就是 long 类型,由于一个元素占用64位,所以一次只能加载2个 int64 元素比较最大值,得出对应位置的最大值: 

最后使用下面的 for 循环代码,从 result 中找到最大的那个 int32 元素,从我们上文的案例中就是4,结果和代码如下所示:

var value = result[0];for (int i = 1; i < Vector128<int>.Count; i++){ if (value < result[i]) { value = result[i]; }}

要注意的是,为了演示方便我这里数组 bit 长度刚好是128倍数,实际情况中需要考虑不是128倍数的场景。


答案显而易见,.NET 7 中 Min() 和 Max() 方法性能暴增45倍的原因就是 Stephe 大佬对基本几个连续的值类型比较做了 SIMD 优化,而这样的优化在本次的 .NET 7 版本中有非常多,有机会的话接下来的文章会带大家一起看看 SIMD 又是如何提升其它方面的性能。

*未经授权请勿私自转载此文章及图片。

扫描下方二维码,马上下载 .NET 7。

点击「阅读原文」下载 .NET 7~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存